#!/usr/bin/env python3

# Copyright (c) 2020-2021 Fpemud <fpemud@sina.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.

import os
import pwd
import glob
import json
import pathlib
import hashlib
from bidict import bidict
from ..._util import Util, CopiedAndSymlinkedDir, IniFile
from ..._game import GameBase, GameSpec, HintForRunner, MatchContext, GameSupportIconDir
from ..._game_straits import GameStraitForWindows, GameStraitNeedConfigDir, GameStraitNeedJoysticks


class NeedForSpeed9(GameBase, GameStraitForWindows, GameSupportIconDir):

    class LevelOfDetail:

        @classmethod
        def max(cls):
            ret = cls()
            ret.CarEnvironmentMapEnable = 3
            ret.CarEnvironmentMapUpdateData = 1
            ret.CarLodLevel = 1
            ret.FSAALevel = 3
            ret.MotionBlurEnable = True
            ret.OverBrightEnable = True
            ret.ParticleSystemEnable = True
            ret.PerformanceLevel = 5
            ret.RainEnable = True
            ret.RoadReflectionEnable = True
            ret.ShadowDetail = 2
            ret.TextureFiltering = 2
            ret.VisualTreatment = 1
            ret.VSyncOn = True
            ret.WorldLodLevel = 3
            return ret

        @classmethod
        def min(cls):
            ret = cls()
            ret.CarEnvironmentMapEnable = 0
            ret.CarEnvironmentMapUpdateData = 0
            ret.CarLodLevel = 0
            ret.FSAALevel = 0
            ret.MotionBlurEnable = False
            ret.OverBrightEnable = False
            ret.ParticleSystemEnable = False
            ret.PerformanceLevel = 0
            ret.RainEnable = False
            ret.RoadReflectionEnable = False
            ret.ShadowDetail = 0
            ret.TextureFiltering = 0
            ret.VisualTreatment = 0
            ret.VSyncOn = True
            ret.WorldLodLevel = 0
            return ret

        def __init__(self):
            self.CarEnvironmentMapEnable = None             # int: 0 - 3
            self.CarEnvironmentMapUpdateData = None         # int: 0 - 1
            self.CarLodLevel = None                         # int: 0 - 1
            self.FSAALevel = None                           # int: 0 - 3
            self.MotionBlurEnable = None                    # bool
            self.OverBrightEnable = None                    # bool
            self.ParticleSystemEnable = None                # bool
            self.PerformanceLevel = None                    # int: 0 - 5
            self.RainEnable = None                          # bool
            self.RoadReflectionEnable = None                # bool
            self.ShadowDetail = None                        # int: 0 - 2
            self.TextureFiltering = None                    # int: 0 - 2
            self.VisualTreatment = None                     # int: 0 - 1
            self.VSyncOn = None                             # bool
            self.WorldLodLevel = None                       # int: 0 - 3

        def check(self, raise_exception=False):
            lst = self.min()
            hst = self.max()
            try:
                if not (lst.CarEnvironmentMapEnable <= self.CarEnvironmentMapEnable <= hst.CarEnvironmentMapEnable):
                    raise ValueError("CarEnvironmentMapEnable")
                if not (lst.CarEnvironmentMapUpdateData <= self.CarEnvironmentMapUpdateData <= hst.CarEnvironmentMapUpdateData):
                    raise ValueError("CarEnvironmentMapUpdateData")
                if not (lst.CarLodLevel <= self.CarLodLevel <= hst.CarLodLevel):
                    raise ValueError("CarLodLevel")
                if not (lst.FSAALevel <= self.FSAALevel <= hst.FSAALevel):
                    raise ValueError("FSAALevel")
                if not isinstance(self.MotionBlurEnable, bool):
                    raise ValueError("MotionBlurEnable")
                if not isinstance(self.OverBrightEnable, bool):
                    raise ValueError("OverBrightEnable")
                if not isinstance(self.ParticleSystemEnable, bool):
                    raise ValueError("ParticleSystemEnable")
                if not (lst.PerformanceLevel <= self.PerformanceLevel <= hst.PerformanceLevel):
                    raise ValueError("PerformanceLevel")
                if not isinstance(self.RainEnable, bool):
                    raise ValueError("RainEnable")
                if not isinstance(self.RoadReflectionEnable, bool):
                    raise ValueError("RoadReflectionEnable")
                if not (lst.ShadowDetail <= self.ShadowDetail <= hst.ShadowDetail):
                    raise ValueError("ShadowDetail")
                if not (lst.TextureFiltering <= self.TextureFiltering <= hst.TextureFiltering):
                    raise ValueError("TextureFiltering")
                if not (lst.VisualTreatment <= self.VisualTreatment <= hst.VisualTreatment):
                    raise ValueError("VisualTreatment")
                if not isinstance(self.VSyncOn, bool):
                    raise ValueError("VSyncOn")
                if not (lst.WorldLodLevel <= self.WorldLodLevel <= hst.WorldLodLevel):
                    raise ValueError("WorldLodLevel")
                return True
            except ValueError:
                if raise_exception:
                    raise
                else:
                    return False

        def __eq__(self, other):
            return self.__class__ == other.__class__ and self.__dict__.values() == other.__dict__.values()

        def __ne__(self, other):
            return not self.__eq__(other)

    def __init__(self, data_dir):
        GameBase.__init__(self, data_dir)
        GameSupportIconDir.__init__(self, os.path.join(self._dataDir, "icons"))

        self._addStrait(GameStraitNeedConfigDir())
        self._addStrait(GameStraitNeedJoysticks())

        # spec
        self._spec = GameSpec()
        if True:
            self._spec.windows_archs = [self.WindowsArch.WIN32]
            self._spec.windows_versions = [self.WindowsVersion.WINXP, "*"]

            self._spec.default_language = "en_US"
            self._spec.languages = ["en_US"]

            self._spec.resolutions = ["640x480", "800x600", "1024x768", "1280x960", "1600x1200"]
            self._spec.can_fullscreen = True
            self._spec.can_windowed = False

            self._spec.graphics_api = ["directx9"]

            # MWFixes by GrimMaple
            # HD Reflections by Aero_
            # Upscaled HUD by LunicAura
            # Xbox 360 Converted Movies by elaymm4
            # Recompiled Vinyls by nlgzrgn
            # Unlimiter by nlgzrgn
            # Xbox Rain Droplets by ThirteenAG
            # Xbox 360 Stuff Pack by osdever & elaymm4
            # Xbox 360 Buttons by Aero_
            # Extra Options by nlgzrgn
            # HUD Resizer by Aero_

        self._curLang = self._spec.default_language

    @property
    def spec(self):
        return self._spec

    @property
    def run_items(self):
        return ["main"]

    def get_title(self, lang):
        # FIXME: should differeciate "black edition"
        return "Need For Speed Most Wanted"

    def _prepare(self, context):
        self._context = context

        try:
            self._openSaveFiles = set()

            self._keyboardMappingFullfn = os.path.join(self._context.config_dirpath, "keyboard.km")
            self._joystickMappingFullfn = os.path.join(self._context.config_dirpath, "joystick.km")

            self._gameIniFile = _GameIniFile(os.path.join(self._context.config_dirpath, "game.ini"))

            # create game files, including language pack files
            self._targetGameDirObj = CopiedAndSymlinkedDir(os.path.join(self._context.drive_c_dirpath, "Need For Speed Most Wanted"), [self._dataDir])
            self._targetGameDirObj.symlink("", recursive=True)

            # disable initial animation
            for name in self.__getCgList():
                if not self._gameIniFile.get_option("cg-%s-enabled" % (name)):
                    self.__enableCg(name, False)

            # copy config files
            self._archiveDir = _GameArchiveDir(self._context.drive_c_dirpath)
            self._archiveDir.import_archives(self._context.config_dirpath)

            # keyboard and joystick key mapping are stored in game archive file, edit game archive files to apply key mapping
            if True:
                try:
                    kdata = json.loads(pathlib.Path(self._keyboardMappingFullfn).read_text())
                except FileNotFoundError:
                    kdata = None

                if len(self._context.joysticks) > 0:
                    try:
                        jdata = json.loads(pathlib.Path(self._joystickMappingFullfn).read_text())
                    except FileNotFoundError:
                        jdata = None
                else:
                    jdata = None

                if kdata is not None or jdata is not None:
                    for name in self._archiveDir.list_archive_names():
                        with GameProfile(self, self._archiveDir.path, name) as f:
                            km = f.use_key_mapping()
                            for key in km.get_keys():
                                if kdata is not None:
                                    # FIXME: kdata is not supported yet
                                    assert False
                                if jdata is not None:
                                    for i in range(0, min(len(self._context.joysticks), 2)):
                                        c = self._context.joysticks[i]
                                        v = Util.dictMGet(jdata, c.get_vendor(), c.get_model(), key, fallback=None)
                                        km.set_joystick_key_mapping(c.get_vendor(), c.get_model(), km.get_roles()[i], key, v)
                            f.save()

            # create registry keys
            self._reg = _GameRegistry(self._context.registry)
            self._reg.writeLang(self._curLang)
            self._reg.writeFirstTime(False)
        except BaseException:
            self._cleanup(on_error=True)
            raise

        hint = HintForRunner()
        hint.selected_joystick_indices = range(0, min(len(self._context.joysticks), 2))
        return hint

    @MatchContext.edit_or_run
    def get_language(self):
        assert False

    @MatchContext.edit_or_run
    def get_keyboard_key_mapping_filepath(self):
        return self._keyboardMappingFullfn

    @MatchContext.edit_or_run
    def get_joystick_key_mapping_filepath(self):
        return self._joystickMappingFullfn

    @MatchContext.edit_or_run
    def is_initial_animation_enabled(self, name):
        if name == "*":
            cgList = self.__getCgList()
        else:
            assert name in self.__getCgList()
            cgList = [name]

        ret = False
        for name in cgList:
            ret |= self._gameIniFile.get_option("cg-%s-enabled" % (name))
        return ret

    @MatchContext.edit_or_run
    def set_language(self, language):
        assert language == self._spec.default_language

    @MatchContext.edit_or_run
    def set_initial_animation_enabled(self, name, value):
        # FIXME: game will stucks as black screen if initial animation is disabled using this solution
        assert value

        if name == "*":
            cgList = self.__getCgList()
        else:
            assert name in self.__getCgList()
            cgList = [name]

        for name in cgList:
            if value != self._gameIniFile.get_option("cg-%s-enabled" % (name)):
                self.__enableCg(name, value)
                self._gameIniFile.set_option("cg-%s-enabled" % (name), value)

    @MatchContext.edit
    def export_keyboard_key_mapping(self, profile_name):
        # FIXME
        assert False

    @MatchContext.edit
    def export_joystick_key_mapping(self, profile_name):
        data = dict()
        with GameProfile(self, self._archiveDir.path, profile_name) as f:
            km = f.get_key_mapping()
            for vendor in km._hwJoystickKeyMap.keys():
                data[vendor] = dict()
                for model in km._hwJoystickKeyMap[vendor].keys():
                    data[vendor][model] = dict()
                    for k in km.get_keys():
                        data[vendor][model][k] = km.get_joystick_key_mapping(vendor, model, "primary", k)

        with open(self._joystickMappingFullfn, "w") as f:
            json.dump(data, f, indent=4)

    @MatchContext.edit
    def list_profiles(self):
        return self._archiveDir.list_archive_names()

    @MatchContext.edit
    def open_profile(self, profile_name):
        return GameProfile(self, self._archiveDir.path, profile_name)

    @MatchContext.edit
    def delete_profile(self, profile_name):
        assert profile_name not in self._openSaveFiles
        os.unlink(os.path.join(self._targetGameDirObj.path, profile_name))

    @MatchContext.run
    def apply_resolution(self, resolution):
        self._reg.writeResolution(resolution)

    @MatchContext.run
    def apply_fullscreen_or_windowed(self, fullscreen_or_windowed):
        assert fullscreen_or_windowed

    @MatchContext.run
    def apply_level_of_detail(self, value):
        assert isinstance(value, self.LevelOfDetail) and value.check(raise_exception=True)
        self._reg.writeLevelOfDetail(value)

    def _commit(self):
        self._gameIniFile.write()

    def _getRunInfo(self, item):
        assert not self._context.edit_or_run
        assert item == Util.listGetOnlyItem(self.run_items)

        # game will crash in wine if cwd is not this directory
        # it is a restriction of wine, but we think it good habit to specify cwd here
        cwd = "C:\\Need For Speed Most Wanted"

        # for Widescreen Fix by ThirteenAG & Aero_
        custom_dlls = []
        if os.path.exists(os.path.join(self._targetGameDirObj.path, "dinput8.dll")):
            custom_dlls.append("dinput8.dll")

        return self.RunInfo("C:\\Need For Speed Most Wanted\\speed.exe", cwd=cwd, custom_dlls=custom_dlls)

    def _cleanup(self, on_error=False):
        warns = []

        if not on_error:
            self._gameIniFile.write()

        if hasattr(self, "_openSaveFiles"):
            assert len(self._openSaveFiles) == 0
            del self._openSaveFiles
        if hasattr(self, "_reg"):
            del self._reg
        if hasattr(self, "_archiveDir"):
            if not on_error:
                self._archiveDir.export_archives(self._context.config_dirpath)
            del self._archiveDir
        if hasattr(self, "_targetGameDirObj"):
            del self._targetGameDirObj
        if hasattr(self, "_joystickMappingFullfn"):
            del self._joystickMappingFullfn
        if hasattr(self, "_keyboardMappingFullfn"):
            del self._keyboardMappingFullfn
        if hasattr(self, "_context"):
            del self._context

    def __getCgList(self):
        return [
            "ealogo",           # EA logo
            "psa",              #
            "attract_movie",    #
        ]

    def __enableCg(self, name, value):
        for fullfn in glob.glob(os.path.join(self._targetGameDirObj.path, "MOVIES", "%s_*.vp6" % (name))):
            relPath = fullfn[len(self._targetGameDirObj.path) + 1:]
            if value:
                self._targetGameDirObj.symlink(relPath)
            else:
                self._targetGameDirObj.unmanage(relPath)
                os.unlink(fullfn)
                pathlib.Path(fullfn).write_bytes(b'')


class GameProfile:

    # FIXME: upstream to https://github.com/BilawalAhmed0900/NFSMWSaveE
    # FIXME: upstream to https://github.com/focanmess/NFSMW_Xbox_Save

    class ParseError(Exception):
        pass

    class _Car:

        def __init__(self):
            self.offset = None
            self.id = None
            self.max_fine_count = None
            self.fine_count = None
            self.bounty = None

    def __init__(self, parent, archiveDir, name):
        self._parent = parent

        self._archiveDir = archiveDir
        self._path = os.path.join(self._archiveDir, name, name)

        self._money = None                      # int
        self._cars = []                         # list<self._Car>
        self._eventCamera = None                # bool
        self._keyMap = _GameKeyMapping()
        self._joystickType = None
        self._joystickRumble = None             # bool
        self._parse()

        self._bChanged = False

        assert name not in self._parent._openSaveFiles
        self._parent._openSaveFiles.add(name)

    @property
    def path(self):
        return self._path

    @property
    def changed(self):
        return self._bChanged

    def get_name(self):
        return os.path.basename(self._path)

    def get_money(self):
        return self._money

    def get_car_count(self):
        return len(self._cars)

    def get_car_bounty(self, car_index):
        return self._cars[car_index].bounty

    def get_key_mapping(self):
        return self._keyMap

    def set_name(self, name):
        if len(name) > 8:
            raise ValueError("profile name must be less than 8 characters")

        newFullfn = os.path.join(self._archiveDir, name, name)
        if os.path.exists(newFullfn):
            raise ValueError("profile \"%s\" already exists" % (name))

        self._parent._openSaveFiles.remove(os.path.basename(self._path))
        self._parent._openSaveFiles.add(name)

        os.rename(self._path, os.path.join(os.path.dirname(self._path), name))      # change filename
        os.rename(os.path.dirname(self._path), os.path.dirname(newFullfn))          # change directory name
        self._path = newFullfn

        self._bChanged = True

    def set_money(self, money):
        if not (0 <= money <= 2**31 - 1):
            raise ValueError("money must be in the range of 0 to 2,147,483,647")
        self._money = money
        self._bChanged = True

    def set_car_bounty(self, car_index, bounty):
        if not (0 <= bounty <= 2**31 - 1):
            raise ValueError("bounty must be in the range of 0 to 2,147,483,647")
        self._cars[car_index].bounty = bounty
        self._bChanged = True

    def use_key_mapping(self):
        self._bChanged = True
        return self._keyMap

    def save(self):
        if not self._bChanged:
            return

        self._keyMap.check(raise_exception=True)

        with open(self._path, "rb+") as inptr:
            # change name specified by user
            inptr.seek(0x5A31)
            bName = os.path.basename(self._path).encode("utf-8")
            inptr.write(bName)
            inptr.write(b'\0' * (8 - len(bName)))

            # change the money specified by user
            inptr.seek(0x4039)
            inptr.write(self._money.to_bytes(4, byteorder="little", signed=False))

            # FIXME: change the bounty of the first car
            carObj = self._cars[0]
            inptr.seek(carObj.offset + 1 + 0xF)     # jump over car ID and 0xF
            inptr.write(carObj.bounty.to_bytes(4, byteorder="little", signed=False))

            # change joystick type
            if any([x is not None for x in self._keyMap["joystick"]["primary"].values()]) or any([x is not None for x in self._keyMap["joystick"]["secondary"].values()]):
                if self._joystickType == b'\xFF\xFF\xFF\xFF':
                    inptr.seek(0xF4C8)
                    inptr.write(b'\xC9\x42\x25\xAB')        # FIXME: is it the only value?
            else:
                if self._joystickType != b'\xFF\xFF\xFF\xFF':
                    inptr.seek(0xF4C8)
                    inptr.write(b'\xFF\xFF\xFF\xFF')

            # change key mapping
            inptr.seek(0xF4CC)
            for key in self._keyMap.get_keys():
                # keyboard
                for role in self._keyMap.get_roles():
                    if self._keyMap["keyboard"][role][key] is not None:
                        self.__writeInt(inptr, 1)
                        self.__writeInt(inptr, self._keyMap["keyboard"][role][key])
                    else:
                        self.__writeInt(inptr, 0)
                        self.__writeInt(inptr, 0)
                    self.__writeInt(inptr, 0)
                # joystick
                for role in self._keyMap.get_roles():
                    if self._keyMap["joystick"][role][key] is not None:
                        self.__writeInt(inptr, self._keyMap["joystick"][role][key][0])
                        self.__writeInt(inptr, 0)
                        self.__writeInt(inptr, self._keyMap["joystick"][role][key][1])
                    else:
                        self.__writeInt(inptr, 0)
                        self.__writeInt(inptr, 0)
                        self.__writeInt(inptr, 0)

            # EA used md5 of the portion 0x34 to F85C for checking the validity of the savefile
            inptr.seek(0x34)
            cksum = int(hashlib.md5(inptr.read(0xF85C - 0x34)).hexdigest(), 16)

            # storing the new md5 at the end
            inptr.seek(0xF85C)
            inptr.write(cksum.to_bytes(16, byteorder="big", signed=False))

        self._bChanged = False

    def close(self):
        self._parent._openSaveFiles.remove(os.path.basename(self._path))

        del self._bChanged
        del self._cars
        del self._money
        del self._path
        del self._archiveDir
        del self._parent

    def __enter__(self):
        return self

    def __exit__(self, type, value, traceback):
        self.close()

    def _parse(self):
        # open the file and check if it is valid i.e. 20CM
        with open(self._path, "rb") as inptr:
            # check magic
            if inptr.read(4) != b'20CM':
                raise self.ParseError("not a Need for Speed Most Wanted archive file")

            # check md5
            if True:
                # calculate md5
                inptr.seek(0x34)
                cksum = int(hashlib.md5(inptr.read(0xF85C - 0x34)).hexdigest(), 16)
                cksum = cksum.to_bytes(16, byteorder="big", signed=False)

                # check the new md5 at the end
                inptr.seek(0xF85C)
                if inptr.read(16) != cksum:
                    raise self.ParseError("invalid checksum")

            # profile name, must be the same as filename
            inptr.seek(0x5A31)
            if inptr.read(8).rstrip(b'\0').decode("utf-8") != os.path.basename(self._path):
                raise self.ParseError("invalid name")

            # joystick rumble
            inptr.seek(0x5BA1)
            self._joystickRumble = self.__readBool(inptr, "rumble")

            # event camera
            inptr.seek(0x5B65)
            self._eventCamera = self.__readBool(inptr, "event camera")

            # money
            inptr.seek(0x4039)
            self._money = self.__readInt(inptr)

            # cars are from 0xE2ED
            # car ID starts from 00 upward, sold car has 0xFF as its ID
            # each car ocuupies 0x38 bytes
            inptr.seek(0xE2ED)
            for i in range(0, 25):
                data = inptr.read(0x38)
                if data[0] == b'\xFF':
                    continue
                carObj = self._Car()
                if True:
                    carObj.offset = 0xE2ED + 0x38 * i
                    carObj.id = data[0]
                    carObj.max_fine_count = data[2]
                    carObj.fine_count = data[3]
                    carObj.bounty = int.from_bytes(data[0x10:0x14], byteorder="little")
                self._cars.append(carObj)
            if len(self._cars) == 0:
                raise self.ParseError("no car found")

            # joystick type
            inptr.seek(0xF4C8)
            if True:
                # joystick type:
                # '\xC9\x42\x25\xAB' - Controller(Xbox One For Windows)
                self._joystickType = inptr.read(4)

            # key mapping
            # every key consumes 48 bytes, including keyboard+primary, keyboard+secondary, joystick+primary, joystick+secondary
            inptr.seek(0xF4CC)
            for key in self._keyMap.get_keys():
                # keyboard
                for role in self._keyMap.get_roles():
                    iSet = self.__readInt(inptr)
                    iKey = self.__readInt(inptr)

                    if key == "pause":
                        if role == "primary":
                            if not (iSet == 1 and iKey == 0x01):
                                raise self.ParseError('invalid keyboard key mapping for "%s"' % (key))
                        elif role == "secondary":
                            if iSet != 0:
                                raise self.ParseError('invalid keyboard key mapping for "%s"' % (key))
                        else:
                            assert False

                    if iSet == 1:
                        self._keyMap["keyboard"][role][key] = iKey
                    elif iSet == 0:
                        if iKey != 0x00:
                            raise self.ParseError("invalid keyboard key mapping for \"%s\"" % (key))
                    else:
                        raise self.ParseError("invalid keyboard key mapping for \"%s\"" % (key))
                    inptr.seek(4, 1)

                # joystick
                if self._joystickType != b'\xFF\xFF\xFF\xFF':
                    for role in _GameKeyMapping.get_roles():
                        iType = self.__readInt(inptr)
                        inptr.seek(4, 1)
                        iKey = self.__readInt(inptr)
                        if iType != 0x00:
                            self._keyMap["joystick"][role][key] = (iType, iKey)
                        else:
                            if iKey != 0x00:
                                raise self.ParseError("invalid joystick key mapping for \"%s\"" % (key))
                else:
                    # no joystick configured, jump over joystick key mapping part
                    inptr.seek(24, 1)

    def __readBool(self, inptr, value_name):
        ret = inptr.read(1)
        if ret == b'\x01':
            return True
        elif ret == b'\x00':
            return False
        else:
            raise self.ParseError("invalid %s value" % (value_name))

    def __readInt(self, inptr):
        return int.from_bytes(inptr.read(4), byteorder="little")

    def __writeInt(self, inptr, value):
        inptr.write(value.to_bytes(4, byteorder="little", signed=False))


class _GameKeyMapping(dict):

    _hwKeyboardKeyMap = bidict({
        0xCB: "arrow_up",
        0xFF: "arrow_left",        # FIXME
        0xCD: "arrow_right",
        0xD0: "arrow_down",
        0x1E: "a",
        0x30: "b",
        0x2E: "c",
        0x22: "g",
        0x26: "l",
        0x32: "m",
        0x19: "p",
        0x13: "r",
        0x14: "t",
        0x2D: "x",
        0x1D: "left_ctrl",
        0x9D: "right_ctrl",
        0x38: "left_alt",
        0x2A: "left_shift",
        0x01: "esc",
        0x39: "space",
        0x1C: "return",
        0x0F: "tab",
    })

    _hwJoystickKeyMap = {
        "microsoft": {
            "xone-controller": bidict({
                (0x02, 0x00): "a-button",
                (0x02, 0x01): "b-button",
                (0x02, 0x02): "x-button",
                (0x02, 0x03): "y-button",
                (0x02, 0x04): "left-bumper",
                (0x02, 0x05): "right-bumper",
                (0x02, 0x06): "view-button",
                (0x02, 0x07): "menu-button",
                (0x03, 0x00): "d-pad-up",
                (0x03, 0x01): "d-pad-right",
                (0x03, 0x02): "d-pad-down",
                (0x03, 0x03): "d-pad-left",
                (0x04, 0x00): "left-stick-x-left",
                (0x05, 0x00): "left-stick-x-right",
                (0x06, 0x00): "left-stick-y-up",
                (0x07, 0x00): "left-stick-y-down",
                (0x04, 0x01): "right-stick-x-left",
                (0x05, 0x01): "right-stick-x-right",
                (0x06, 0x01): "right-stick-y-up",
                (0x07, 0x01): "right-stick-y-down",
                (0x04, 0x02): "right-trigger",
                (0x05, 0x02): "left-trigger",
            }),
        },
    }

    @staticmethod
    def get_keys():
        return [
            "accelerate",
            "brake_reverse",
            "steer_left",
            "steer_right",
            "handbrake",
            "speedbreaker",
            "n20",
            "shift_down",
            "shift_up",
            "reset_car_position",
            "engage_event",
            "view_blacklist",
            "view_world_map",
            "view_sms_message",
            "play_next_song",
            "change_camera_view",
            "look_behind",
            "pull_camera_back",
            "pause",
        ]

    @staticmethod
    def get_roles():
        return [
            "primary",
            "secondary",
        ]

    def __init__(self):
        super().__init__()

        self["keyboard"] = dict()
        for role in self.get_roles():
            self["keyboard"][role] = {k: None for k in self.get_keys()}

        self["joystick"] = dict()
        for role in self.get_roles():
            self["joystick"][role] = {k: None for k in self.get_keys()}

    def get_keyboard_key_mapping(self, vendor, model, role, key):
        ret = self["keyboard"][role][key]
        if ret is not None:
            # FIXME
            assert False
        else:
            return None

    def set_keyboard_key_mapping(self, vendor, model, role, key, value):
        # FIXME
        assert False

    def get_joystick_key_mapping(self, vendor, model, role, key):
        ret = self["joystick"][role][key]
        if ret is not None:
            return self._hwJoystickKeyMap[vendor][model][ret]
        else:
            return None

    def set_joystick_key_mapping(self, vendor, model, role, key, value):
        if value is not None:
            self["joystick"][role][key] = self._hwJoystickKeyMap[vendor][model].inverse[value]
        else:
            self["joystick"][role][key] = None

    def check(self, raise_exception=False):
        try:
            # "pause" keyboard key mapping for primary user must be ESC key
            if self["keyboard"]["primary"]["pause"] != 0x01:
                raise ValueError('invalid "pause" keyboard key mapping for primary user')

            # "pause" keyboard key mapping for secondary user must not exist
            if self["keyboard"]["secondary"]["pause"] is not None:
                raise ValueError('invalid "pause" keyboard key mapping for secondary user')

            # duplicate value should not exist
            for n in ["keyboard", "joystick"]:
                vDict = dict()
                for role in self.get_roles():
                    for key in self.get_keys():
                        v = self["keyboard"][role][key]
                        if v is not None:
                            if v in vDict:
                                oldRole, oldKey = vDict[v]
                                raise ValueError('duplicate mapping found between key "%s" for %s user and "%s" for %s user' % (oldKey, oldRole, key, role))
                            vDict[v] = (role, key)

            return True
        except ValueError:
            if raise_exception:
                raise
            else:
                return False


class _GameRegistry:

    _keyName = "HKEY_LOCAL_MACHINE\\Software\\EA GAMES\\Need For Speed Most Wanted"

    _resolutionToNumberDict = bidict({
        "640x480": 1,
        "800x600": 2,
        "1024x768": 3,
        "1280x960": 4,
        "1600x1200": 5,
    })

    _langDict = {
        "en_US": {
            "Language": "English US",
            "Locale": "en_us",
        },
        "zh_TW": {
            "Language": "Chinese (Traditional)",
            "Locale": "zh_tw",
        },
    }

    def __init__(self, regObj):
        self._regObj = regObj

    def readLang(self):
        ret = self._regObj.get_value(self._keyName, "Locale", "REG_SZ")
        for k, v in self._langDict.items():
            if v["Locale"] == ret:
                return k
        assert False

    def writeLang(self, lang):
        for k, v in self._langDict[lang].items():
            self._regObj.add_value(self._keyName, k, "REG_SZ", v)

    def readFirstTime(self):
        ret = self._regObj.get_value(self._keyName, "FirstTime", "REG_DWORD", fallback=1)
        return ret != 0

    def writeFirstTime(self, value):
        if value:
            assert False
        else:
            self._regObj.add_value(self._keyName, "FirstTime", "REG_DWORD", 0)

    def readResolution(self):
        ret = self._regObj.get_value(self._keyName, "g_RacingResolution", "REG_DWORD", fallback=1)
        return self._resolutionToNumberDict.inverse[ret]

    def writeResolution(self, resolution):
        resolution = self._resolutionToNumberDict[resolution]
        self._regObj.add_value(self._keyName, "g_RacingResolution", "REG_DWORD", resolution)

    def readLevelOfDetail(self):
        ret = NeedForSpeed9.LevelOfDetail()

        lst = NeedForSpeed9.LevelOfDetail.min()
        if True:
            cur = "CarEnvironmentMapEnable"
            ret.CarEnvironmentMapEnable = self._regObj.get_value(self._keyName, "g_%s" % (cur), "REG_DWORD", fallback=getattr(lst, cur))
        if True:
            cur = "CarEnvironmentMapEnable"
            ret.CarEnvironmentMapUpdateData = self._regObj.get_value(self._keyName, "g_%s" % (cur), "REG_DWORD", fallback=getattr(lst, cur))
        if True:
            cur = "CarEnvironmentMapEnable"
            ret.CarLodLevel = self._regObj.get_value(self._keyName, "g_%s" % (cur), "REG_DWORD", fallback=getattr(lst, cur))
        if True:
            cur = "CarEnvironmentMapEnable"
            ret.FSAALevel = self._regObj.get_value(self._keyName, "g_%s" % (cur), "REG_DWORD", fallback=getattr(lst, cur))
        if True:
            cur = "CarEnvironmentMapEnable"
            ret.MotionBlurEnable = bool(self._regObj.get_value(self._keyName, "g_%s" % (cur), "REG_DWORD", fallback=getattr(lst, cur)))
        if True:
            cur = "CarEnvironmentMapEnable"
            ret.OverBrightEnable = bool(self._regObj.get_value(self._keyName, "g_%s" % (cur), "REG_DWORD", fallback=getattr(lst, cur)))
        if True:
            cur = "CarEnvironmentMapEnable"
            ret.ParticleSystemEnable = bool(self._regObj.get_value(self._keyName, "g_%s" % (cur), "REG_DWORD", fallback=getattr(lst, cur)))
        if True:
            cur = "CarEnvironmentMapEnable"
            ret.PerformanceLevel = self._regObj.get_value(self._keyName, "g_%s" % (cur), "REG_DWORD", fallback=getattr(lst, cur))
        if True:
            cur = "CarEnvironmentMapEnable"
            ret.RainEnable = bool(self._regObj.get_value(self._keyName, "g_%s" % (cur), "REG_DWORD", fallback=getattr(lst, cur)))
        if True:
            cur = "CarEnvironmentMapEnable"
            ret.RoadReflectionEnable = bool(self._regObj.get_value(self._keyName, "g_%s" % (cur), "REG_DWORD", fallback=getattr(lst, cur)))
        if True:
            cur = "CarEnvironmentMapEnable"
            ret.ShadowDetail = self._regObj.get_value(self._keyName, "g_%s" % (cur), "REG_DWORD", fallback=getattr(lst, cur))
        if True:
            cur = "CarEnvironmentMapEnable"
            ret.TextureFiltering = self._regObj.get_value(self._keyName, "g_%s" % (cur), "REG_DWORD", fallback=getattr(lst, cur))
        if True:
            cur = "CarEnvironmentMapEnable"
            ret.VisualTreatment = self._regObj.get_value(self._keyName, "g_%s" % (cur), "REG_DWORD", fallback=getattr(lst, cur))
        if True:
            cur = "CarEnvironmentMapEnable"
            ret.VSyncOn = bool(self._regObj.get_value(self._keyName, "g_%s" % (cur), "REG_DWORD", fallback=getattr(lst, cur)))
        if True:
            cur = "CarEnvironmentMapEnable"
            ret.WorldLodLevel = self._regObj.get_value(self._keyName, "g_%s" % (cur), "REG_DWORD", fallback=getattr(lst, cur))

        return ret

    def writeLevelOfDetail(self, lod):
        assert lod.check()
        for k, v in lod.__dict__.items():
            self._regObj.add_value(self._keyName, "g_%s" % (k), "REG_DWORD", int(v))


class _GameArchiveDir:

    def __init__(self, drive_c_dirpath):
        self._username = pwd.getpwuid(os.geteuid()).pw_name
        self._docDir = os.path.join(drive_c_dirpath, "users", self._username, "Documents")

    @property
    def path(self):
        return os.path.join(self._docDir, "NFS Most Wanted")

    def list_archive_names(self):
        tdir = os.path.join(self._docDir, "NFS Most Wanted")
        if os.path.isdir(tdir):
            return [x for x in os.listdir(tdir) if os.path.isdir(os.path.join(tdir, x))]
        else:
            return []

    def import_archives(self, backup_archive_dir):
        srcDir = os.path.join(backup_archive_dir, "NFS Most Wanted")
        dstDir = os.path.join(self._docDir, "NFS Most Wanted")
        if os.path.isdir(srcDir):
            Util.forceCopy(srcDir, dstDir)
        else:
            Util.forceDelete(dstDir)

    def export_archives(self, backup_archive_dir):
        srcDir = os.path.join(self._docDir, "NFS Most Wanted")
        dstDir = os.path.join(backup_archive_dir, "NFS Most Wanted")
        if os.path.isdir(srcDir):
            Util.forceCopy(srcDir, dstDir)
        else:
            Util.forceDelete(dstDir)


class _GameIniFile:

    def __init__(self, filepath):
        self._iniFile = IniFile(filepath)
        self._iniFile.set_value_type_and_fallback("main", "cg-ealogo-enabled", bool, True)
        self._iniFile.set_value_type_and_fallback("main", "cg-psa-enabled", bool, True)
        self._iniFile.set_value_type_and_fallback("main", "cg-attract_movie-enabled", bool, True)

    def get_option(self, option):
        return self._iniFile.get_option("main", option, None)

    def set_option(self, option, value):
        if value == self._iniFile.get_fallback("main", option):
            self._iniFile.remove_option("main", option)
        else:
            self._iniFile.set_option("main", option, None, value)

    def write(self):
        self._iniFile.write()

    def reset(self):
        self._iniFile.reset()


# what are these?
# REM Set NFSMW SwapSize to 200 MB
# %REG32% ADD "HKLM\SOFTWARE\EA GAMES\Need for Speed Most Wanted" /v SwapSize /d "209715200" /f
# REM Set NFSMW CPU Priority to High
# %REG64% ADD "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\speed.exe\PerfOptions" /v CpuPriorityClass /t REG_DWORD /d "00000003" /f
# REM Enable Large Memory Pages for NFSMW
# %REG64% ADD "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\speed.exe\PerfOptions" /v UseLargePages /t REG_DWORD /d "00000001" /f
